Skip to content

Conversation

eyeinsky
Copy link
Collaborator

@eyeinsky eyeinsky commented Sep 23, 2025

Task checklist:

  • add field searchable
  • default value is True
  • must only be changed from the new /users/:uid/searchable API endpoint and by team admin, and nowhere else
  • add permission SetMemberSearchable
  • must be exposed from user profile
  • must be indexed by ES
  • /search/contacts must filter based on searchable = True
  • /teams/:tid/search must expose a filter to find non-searchable users; otherwise ignore it
  • exact handle search via public endpoint must return 404
  • calling HEAD should ignore the field: used to check whether handle is taken
  • add changelog entry for searchable field
  • adds a test to check the above
  • exact handle search via internal endpoint should ignore this property, if no such endpoint exists, it should be created We'll do this in another task.

Open questions

  • is the changed admin toggle API type ok? The task designates this as POST /users/:uid/searchable, but as TeamId is required, then it currently is POST /users/:uid/:tid/searchable -- could this be improved?
  • POST /handles: should this endpoint also filter based on searchability?
  • are there no other locations than /search/contacts to take searchability into account?
  • should the test be moved into the integration package? (@battermann mentioned this -- are we moving tests there and hence, should this test also go there as well?)

Checklist

  • Add a new entry in an appropriate subdirectory of changelog.d
  • Read and follow the PR guidelines

@zebot zebot added the ok-to-test Approved for running tests in CI, overrides not-ok-to-test if both labels exist label Sep 23, 2025
@eyeinsky eyeinsky force-pushed the ml/WPB-20214-user-searchable branch 5 times, most recently from bc31287 to 78ad65e Compare September 25, 2025 08:44
@eyeinsky
Copy link
Collaborator Author

eyeinsky commented Sep 25, 2025

The below is now resolved.


The current error when running the test is this:

[[email protected]] E, IO Exception occurred, message=cql-io: protocol error: parse error: response body reading: Failed reading: column count: 24 =/= 26 Empty call stack , request=37d3c1fa-f1ee-4050-b623-e4c9c79e5584
[[email protected]] E, request=37d3c1fa-f1ee-4050-b623-e4c9c79e5584, code=500, label=server-error, "Server Error"
brig-integration: Assertions failed:
 1: 201 =/= 500

Response was:

Response {responseStatus = Status {statusCode = 500, statusMessage = "Internal Server Error"}, responseVersion = HTTP/1.1, responseHeaders = [("Transfer-Encoding","chunked"),("Date","Thu, 25 Sep 2025 09:00:01 GMT"),("Server","Warp/3.4.2"),("traceparent","00-96836cd3b1eedf0553338dde6b2d4b0c-1ced0252881c377c-01"),("tracestate",""),("Content-Encoding","gzip"),("Content-Type","application/json"),("Vary","Accept-Encoding")], responseBody = Just "{\"code\":500,\"label\":\"server-error\",\"message\":\"Internal Server Error\"}", responseCookieJar = CJ {expose = []}, responseClose' = ResponseClose, responseOriginalRequest = Request {
  host                 = "127.0.0.1"
  port                 = 8082
  secure               = False
  requestHeaders       = [("Content-Type","application/json")]
  path                 = "/i/users"
  queryString          = ""
  method               = "POST"
  proxy                = Nothing
  rawBody              = False
  redirectCount        = 10
  responseTimeout      = ResponseTimeoutDefault
  requestVersion       = HTTP/1.1
  proxySecureMode      = ProxySecureWithConnect
}
, responseEarlyHints = []}
CallStack (from HasCallStack):
  error, called at src/Bilge/Assert.hs:91:5 in bilge-0.22.0-inplace:Bilge.Assert
  <!!, called at test/integration/API/Team/Util.hs:140:9 in brig-2.0-inplace-brig-integration:API.Team.Util
  createUserWithTeam', called at test/integration/API/Team/Util.hs:89:21 in brig-2.0-inplace-brig-integration:API.Team.Util
  createPopulatedBindingTeamWithNames, called at test/integration/API/Team/Util.hs:68:25 in brig-2.0-inplace-brig-integration:API.Team.Util
  createPopulatedBindingTeamWithNamesAndHandles, called at test/integration/API/Search.hs:165:38 in brig-2.0-inplace-brig-integration:API.Search

From running this command: make c package=all && ./hack/bin/cabal-run-integration.sh brig -p '/testUserSearchable/'

@eyeinsky eyeinsky force-pushed the ml/WPB-20214-user-searchable branch 3 times, most recently from 9891c56 to 6a1a1b8 Compare October 3, 2025 07:56
@eyeinsky eyeinsky marked this pull request as ready for review October 3, 2025 08:17
@eyeinsky eyeinsky requested a review from a team as a code owner October 3, 2025 08:17
@eyeinsky eyeinsky force-pushed the ml/WPB-20214-user-searchable branch from 6a1a1b8 to 4790b05 Compare October 6, 2025 09:59
@eyeinsky
Copy link
Collaborator Author

eyeinsky commented Oct 6, 2025

@akshaymankar Noting here what you asked about in the standup: the /search/contacts endpoint fetches the results from ES which is populated from Cassandra's user table. This has the searchable field, so filtering there is easy. The /team/:tid/members?searchable=false on the other hand gets its results from Cassandra's team_member table, which doesn't have a searchable field.. But since I need to return all non-searchable users from this table, then I think the current (pre-postgres) way to do it would be to get all rows from team_member and then filter that down to only non-searchable users by the help of users table. With large table this might costly to do. 🤔

When we had all data in postgres though, the query could entirely be done on the database side: join team_member with user, then filter by searchable.

@akshaymankar
Copy link
Member

@eyeinsky I think we shouldn't support this query param on /teams/:tid/members endpoint, but rather on /teams/:tid/search endpoint. I just saw the ticket and it seems like I made a mistake in the name of this endpoint. Sorry about that 😞

@eyeinsky eyeinsky force-pushed the ml/WPB-20214-user-searchable branch from 4790b05 to b0f782c Compare October 7, 2025 09:47
@eyeinsky
Copy link
Collaborator Author

eyeinsky commented Oct 7, 2025

@akshaymankar I'm ready for another round of review!

@akshaymankar
Copy link
Member

is the changed admin toggle API type ok? The task designates this as POST /users/:uid/searchable, but as TeamId is required, then it currently is POST /users/:uid/:tid/searchable -- could this be improved?

I think we can just lookup the team id of the targetted user and check whether the user who made the request is an admin of that team. We shouldn't need team id in the path.

POST /handles: should this endpoint also filter based on searchability?

No, we can expose the fact that his handle is taken as far as we don't tell who has this handle.

are there no other locations than /search/contacts to take searchability into account?

Yeah, this is the only location.

should the test be moved into the integration package? (@battermann mentioned this -- are we moving tests there and hence, should this test also go there as well?)

Ideally, yes. Sometimes we've been lazy, but the agreed upon rule was to move a test if you need to work on it and not to add more tests in the legacy test suites.

@eyeinsky eyeinsky force-pushed the ml/WPB-20214-user-searchable branch 3 times, most recently from c0e5357 to cfc7888 Compare October 10, 2025 09:27
Copy link
Contributor

@battermann battermann left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a release note explaining how the search index mapping has to be updated and how to avoid downtime, etc.

And there are some minor comments, looks good overall.

Comment on lines 356 to 359
let -- Helper to change user searchability.
setSearchable self uid searchable = do
req <- baseRequest self Brig Versioned $ joinHttpPath ["users", uid, "searchable"]
submit "POST" $ addJSON searchable req
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should go into API.Brig

Comment on lines 371 to 372
u1' <- BrigP.getUser u1 u1 >>= getJSON 200
assertBool "Searchable is still True" =<< (u1' %. "searchable" & asBool)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If u1' is not used later I would prefer to do the assertion in a bindResponse body, to reduce number of ticked variable names. Also you could assert like this: u1 %. "searchable" shouldMatch True (I think) which I find more readable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The above applies to the following lines, too.

:> QueryParam'
[ Optional,
Strict,
Description "Optional, return only non-seacrhable members when false."
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will it return only searchable members when true?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would have passed the variable explicitly forward to ES, but that would have been confusing (?searchable=true would have returned users where this flag is explicitly set to true), so it is now changed at the latest iteration 2ced710.

Comment on lines 291 to 299
randomUserPrefix ::
(MonadCatch m, MonadIO m, MonadHttp m, HasCallStack) =>
Text ->
Brig ->
m User
randomUserPrefix prefix brig = do
n <- fromName <$> randomName
createUser' True (prefix <> n) brig

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems unused, no?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, removed!

@eyeinsky eyeinsky force-pushed the ml/WPB-20214-user-searchable branch 2 times, most recently from 833088a to d8fb31c Compare October 10, 2025 12:40
@eyeinsky
Copy link
Collaborator Author

eyeinsky commented Oct 10, 2025

@battermann I've now adapted to all of the above comments in the latest state of this branch (7b3b5e5).

Edit: Ah, changelog entry incoming.

@eyeinsky eyeinsky requested review from a team as code owners October 10, 2025 13:34
@battermann battermann requested a review from Copilot October 10, 2025 15:03
Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR implements member searchability functionality by adding a searchable field to users that controls their visibility in public search endpoints. When set to false, users won't be found through public search but remain accessible to team admins through dedicated endpoints.

Key changes:

  • Added searchable boolean field to user data structures with default value true
  • Implemented POST /users/:uid/searchable API endpoint with team admin permission requirements
  • Modified search endpoints to filter based on searchability while preserving admin access

Reviewed Changes

Copilot reviewed 31 out of 36 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
libs/wire-api/src/Wire/API/User.hs Added profileSearchable and userSearchable fields to user data types
libs/wire-api/src/Wire/API/Team/Member.hs Added SetMemberSearchable permission for team admins
libs/wire-api/src/Wire/API/Routes/Public/Brig.hs Added API route for setting user searchability
libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs Implemented searchability filtering in local search and admin endpoint
libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs Added Elasticsearch filtering for searchable users
services/brig/src/Brig/Data/User.hs Updated database schema and queries to include searchable field
integration/test/Test/Search.hs Added comprehensive integration tests for searchability feature
changelog.d/2-features/WPB-20214 Added feature changelog entry

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

managedBy = managedBy,
supportedProtocols = prots
supportedProtocols = prots,
searchable = True -- NewUser doesn't have this field. TODO: should a defSearchable be added to Wire.API.User to be used everywhere?
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TODO comment suggests adding a default constant but leaves it unresolved. Consider either implementing the suggested defSearchable constant in Wire.API.User or removing this TODO if the current approach is acceptable.

Copilot uses AI. Check for mistakes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a40d240, we add it when we need it.

udSso = Nothing,
udEmailUnvalidated = Nothing
udEmailUnvalidated = Nothing,
udSearchable = Nothing -- TODO: or Just True?
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TODO comment questions whether this should be Nothing or Just True. This uncertainty in test data could indicate unclear requirements. Consider resolving this based on the expected behavior for legacy data without the searchable field.

Suggested change
udSearchable = Nothing -- TODO: or Just True?
udSearchable = Nothing

Copilot uses AI. Check for mistakes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ffba78f, keeping it as Nothing as if the value didn't exist in Elastic Search.

Comment on lines +131 to +133
--
-- If you ever think about adding a new permission flag, read Note
-- [team roles] first.
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The comment about reading 'Note [team roles]' was moved from after the data type definition to before it, but the new SetMemberSearchable permission was added without any indication that this note was consulted. Consider adding a comment confirming this note was reviewed for the new permission.

Copilot uses AI. Check for mistakes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it was moved, it was definitely read. I moved it because it relates to the entire data type (as when it was next to a data constructor, it looked as if it only mattered to that particular one).

Assertions () ->
m ()
(!!!) io = void . (<!!) io
io !!! aa = void (io <!! aa)
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change from pattern matching to direct function composition appears to be unrelated to the searchability feature. Consider moving this refactoring to a separate commit or PR to maintain clear separation of concerns.

Suggested change
io !!! aa = void (io <!! aa)
m !!! a = do
_ <- m <!! a
pure ()

Copilot uses AI. Check for mistakes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is part of a functionally no-op refactor commit. The single line version is shorter, but still clear.

Comment on lines 163 to 164
type UserAPI =
-- See Note [ephemeral user sideeffect]
Named
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removal of 'See Note [ephemeral user sideeffect]' comments appears unrelated to the searchability feature. These documentation changes should be part of a separate commit focused on documentation cleanup.

Copilot uses AI. Check for mistakes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These removals are in a separate commit.

@eyeinsky
Copy link
Collaborator Author

Right, changed it back.

@eyeinsky
Copy link
Collaborator Author

@akshaymankar I fixed the last issue here 5acb9f7, and also test that the result for /team/:tid/search and /team/:tid/search?searchable=true is the same here (for me it doesn't scroll to the right place for some reason, but it's highlighted).

@eyeinsky
Copy link
Collaborator Author

Now re-running CI after make sanitize-pr.

@eyeinsky eyeinsky force-pushed the ml/WPB-20214-user-searchable branch from 329316d to 8e395fa Compare October 15, 2025 14:25
@eyeinsky
Copy link
Collaborator Author

Bumping CI, the Demo.testDynamicBackend test failed, but that seems unrelated.

{ setSearchable :: Bool
}
deriving (Generic)
deriving anyclass (Aeson.ToJSON, Aeson.FromJSON, S.ToSchema)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would create camelCase JSON fields. We use snake_case everywhere. Can you please use ToSchema from Data.Schema and then deriving ToJSON, FromJSON and S.ToSchema.

Comment on lines 448 to 461
-- /teams/:tid/search and /teams/:tid/search?searchable=true both get all members, searchable and non-searchable
noQueryParam <-
BrigP.searchTeam admin [] `bindResponse` \resp -> do
resp.status `shouldMatchInt` 200
docs <- resp.json %. "documents" >>= asList
mapM (\m -> m %. "id" & asString) docs
withQueryParam <-
BrigP.searchTeam admin [("searchable", "true")] `bindResponse` \resp -> do
resp.status `shouldMatchInt` 200
docs <- resp.json %. "documents" >>= asList
mapM (\m -> m %. "id" & asString) docs
assertBool "/teams/:tid/search and /teams/:tid/search?searchable=true are equal"
$ Set.fromList noQueryParam
== Set.fromList withQueryParam
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are not the same cases. When searchable=true we return only searchable people, But when it is not set we should return all users. Otherwise there is no way for the admin to see all the users.

@eyeinsky
Copy link
Collaborator Author

@akshaymankar I've now hopefully implemented the correct behavior to /teams/:tid/search!

Also, on Friday I added a test for the legacy situation of where searchable field is missing from ES here to services/brig/test/integration/API/Search.hs (there, because ES config is made accessible there from the test setup).

Copy link
Member

@akshaymankar akshaymankar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, thanks! :shipit:

@akshaymankar akshaymankar dismissed battermann’s stale review October 20, 2025 08:35

@battermann is on vacation, I've reviewed the work now.

@eyeinsky eyeinsky merged commit b51531c into develop Oct 20, 2025
9 checks passed
@eyeinsky eyeinsky deleted the ml/WPB-20214-user-searchable branch October 20, 2025 08:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ok-to-test Approved for running tests in CI, overrides not-ok-to-test if both labels exist

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants